Skip to content

Replace drizzle-orm with native ObjectQL implementation using better-auth naming conventions#582

Merged
hotlong merged 6 commits intocopilot/start-better-auth-implementationfrom
copilot/implement-objectql-database-objects
Feb 10, 2026
Merged

Replace drizzle-orm with native ObjectQL implementation using better-auth naming conventions#582
hotlong merged 6 commits intocopilot/start-better-auth-implementationfrom
copilot/implement-objectql-database-objects

Conversation

Copy link
Contributor

Copilot AI commented Feb 10, 2026

PR #580 integrated better-auth with drizzle-orm as database adapter. This PR replaces that with native ObjectQL implementation, eliminating the third-party ORM dependency while maintaining better-auth's native naming conventions for seamless migration.

Changes

Auth Object Definitions (src/objects/*.object.ts)

  • user, session, account, verification defined using ObjectSchema.create()
  • Uses better-auth's native table names (no auth_ prefix)
  • Uses better-auth's native field naming (camelCase): emailVerified, createdAt, userId, etc.
  • Maps directly to better-auth's schema requirements for seamless migration

ObjectQL Adapter (src/objectql-adapter.ts)

  • Implements better-auth's CustomAdapter interface using ObjectQL's IDataEngine
  • No name translation needed - uses better-auth's native naming conventions directly
  • Converts better-auth where clauses to ObjectQL filter format
  • Sequential updates/deletes due to IDataEngine requiring IDs (noted for future optimization)

Integration

  • AuthManager accepts dataEngine parameter, automatically injected by kernel's data service
  • Removed databaseUrl parameter (obsolete with ObjectQL)
  • Removed drizzle-orm from peerDependencies

Example

// Object definitions use better-auth's native naming
export const AuthUser = ObjectSchema.create({
  name: 'user',  // better-auth native table name
  fields: {
    email: Field.email({ label: 'Email', required: true }),
    emailVerified: Field.boolean({ label: 'Email Verified' }),  // camelCase
    createdAt: Field.datetime({ label: 'Created At' }),  // camelCase
    // ...
  },
  indexes: [{ fields: ['email'], unique: true }]
});

// Adapter bridges better-auth to ObjectQL (no name conversion)
const adapter = createObjectQLAdapter(dataEngine);
const auth = betterAuth({ database: adapter });

Benefits

  • Seamless Migration: Existing better-auth databases work without schema changes
  • Driver-agnostic: Works with any ObjectQL driver (memory, SQL, NoSQL) without ORM coupling
  • Simplified Adapter: No name conversion logic needed
  • Better-auth Compatibility: Full compatibility with better-auth ecosystem
Original prompt

拉取请求: https://github.com/objectstack-ai/spec/pull/580/files
数据库应该基于objectql自己创建对象实现。不要使用第三方orm


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel
Copy link

vercel bot commented Feb 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-studio Ready Ready Preview, Comment Feb 10, 2026 6:30am
spec Ready Ready Preview, Comment Feb 10, 2026 6:30am

Request Review

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement database using ObjectQL objects Replace drizzle-orm with native ObjectQL implementation for auth persistence Feb 10, 2026
Copilot AI requested a review from hotlong February 10, 2026 06:09
@hotlong
Copy link
Contributor

hotlong commented Feb 10, 2026

@copilot 所有的数据库表名和字段名使用better-auth现有的规范,确保现有的客户能无缝迁移

- Changed table names: auth_user → user, auth_session → session, etc.
- Changed field names: email_verified → emailVerified, created_at → createdAt, etc.
- Removed name conversion logic from adapter (no longer needed)
- Updated documentation to reflect better-auth native schema usage
- All tests passing (11/11)

This ensures existing better-auth databases can migrate without schema changes.

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 10, 2026

@copilot 所有的数据库表名和字段名使用better-auth现有的规范,确保现有的客户能无缝迁移

已完成。现在使用 better-auth 的原生命名规范:

表名: user, session, account, verification (不再使用 auth_ 前缀)
字段名: emailVerified, createdAt, userId 等 (使用 camelCase,不再使用 snake_case)

适配器已简化,不再需要名称转换逻辑。现有 better-auth 数据库可以无缝迁移到 ObjectQL。

Commit: d746197

Copilot AI changed the title Replace drizzle-orm with native ObjectQL implementation for auth persistence Replace drizzle-orm with native ObjectQL implementation using better-auth naming conventions Feb 10, 2026
@hotlong hotlong marked this pull request as ready for review February 10, 2026 06:32
Copilot AI review requested due to automatic review settings February 10, 2026 06:32
@hotlong hotlong merged commit bc0f249 into copilot/start-better-auth-implementation Feb 10, 2026
3 checks passed
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates @objectstack/plugin-auth to persist better-auth data via a native ObjectQL IDataEngine adapter, removing the plugin’s direct drizzle-orm dependency and introducing explicit auth object definitions that follow better-auth’s native naming.

Changes:

  • Added ObjectQL-backed better-auth adapter (createObjectQLAdapter) and wired AuthManager to use it when a dataEngine is provided.
  • Added Object Protocol object definitions for better-auth tables (user, session, account, verification) and exported them from the plugin.
  • Updated plugin init/docs/deps to reflect ObjectQL-based persistence and removed drizzle-orm peer dependency.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
pnpm-lock.yaml Removes drizzle-orm from plugin-auth importer deps; lockfile still shows better-auth resolved with drizzle-orm peer.
packages/plugins/plugin-auth/src/objects/index.ts New barrel export for auth Object Protocol definitions.
packages/plugins/plugin-auth/src/objects/auth-user.object.ts Adds user object schema matching better-auth naming.
packages/plugins/plugin-auth/src/objects/auth-session.object.ts Adds session object schema matching better-auth naming.
packages/plugins/plugin-auth/src/objects/auth-account.object.ts Adds account object schema matching better-auth naming.
packages/plugins/plugin-auth/src/objects/auth-verification.object.ts Adds verification object schema matching better-auth naming.
packages/plugins/plugin-auth/src/objectql-adapter.ts Implements better-auth adapter on top of IDataEngine (where translation + CRUD).
packages/plugins/plugin-auth/src/index.ts Exports adapter + object definitions from plugin entrypoint.
packages/plugins/plugin-auth/src/auth-plugin.ts Injects dataEngine from kernel data service into AuthManager.
packages/plugins/plugin-auth/src/auth-manager.ts Uses ObjectQL adapter when dataEngine is provided; warns/falls back otherwise.
packages/plugins/plugin-auth/package.json Removes drizzle-orm peer dependency.
packages/plugins/plugin-auth/README.md Updates docs to describe ObjectQL persistence and new architecture.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

} else if (condition.operator === 'lte') {
filter[fieldName] = { $lte: condition.value };
} else if (condition.operator === 'contains') {
filter[fieldName] = { $regex: condition.value };
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectQL filter protocol uses string operators like $contains/$startsWith/$endsWith (see packages/spec/src/data/filter.zod.ts), but contains is currently mapped to $regex. Map contains to $contains to stay compatible with ObjectQL drivers that validate against the protocol schema.

Suggested change
filter[fieldName] = { $regex: condition.value };
filter[fieldName] = { $contains: condition.value };

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +95
const results = await dataEngine.find(objectName, {
filter,
limit: limit || 100,
skip: offset,
sort,
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit: limit || 100 changes semantics for valid falsy values and can introduce an unexpected cap if limit is omitted. Prefer passing limit through as-is (or use nullish coalescing) so caller intent is preserved and it stays consistent with DataEngineQueryOptions.limit being optional.

Copilot uses AI. Check for mistakes.
Comment on lines 609 to 611
better-auth:
specifier: ^1.4.18
version: 1.4.18(drizzle-orm@0.41.0(kysely@0.28.11))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@types/node@25.2.2)(happy-dom@20.5.3)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(tsx@4.21.0))
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lockfile still resolves better-auth with a drizzle-orm@0.41.0 peer (it appears in the resolved version string). If the goal is to remove the ORM dependency, regenerate pnpm-lock.yaml after removing drizzle-orm so the resolved better-auth snapshot no longer includes it (or ensure the peer isn't auto-installed).

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +80
// Get data engine service for database operations
const dataEngine = ctx.getService<any>('data');
if (!dataEngine) {
ctx.logger.warn('No data engine service found - auth will use in-memory storage');
}

// Initialize auth manager with data engine
this.authManager = new AuthManager({
...this.options,
dataEngine,
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change introduces new behavior (injecting dataEngine and supporting a missing data service) but there are no assertions covering it in the existing plugin test suite. Add tests that verify: (1) init does not throw when data is absent, and (2) when data is present it is passed into AuthManager.

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +49
export function createObjectQLAdapter(dataEngine: IDataEngine) {
/**
* Convert better-auth where clause to ObjectQL query format
*/
function convertWhere(where: CleanedWhere[]): Record<string, any> {
const filter: Record<string, any> = {};

for (const condition of where) {
// Use field names as-is (no conversion needed)
const fieldName = condition.field;

if (condition.operator === 'eq') {
filter[fieldName] = condition.value;
} else if (condition.operator === 'ne') {
filter[fieldName] = { $ne: condition.value };
} else if (condition.operator === 'in') {
filter[fieldName] = { $in: condition.value };
} else if (condition.operator === 'gt') {
filter[fieldName] = { $gt: condition.value };
} else if (condition.operator === 'gte') {
filter[fieldName] = { $gte: condition.value };
} else if (condition.operator === 'lt') {
filter[fieldName] = { $lt: condition.value };
} else if (condition.operator === 'lte') {
filter[fieldName] = { $lte: condition.value };
} else if (condition.operator === 'contains') {
filter[fieldName] = { $regex: condition.value };
}
}

return filter;
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adapter’s where-clause translation is core to correctness (operator mapping, multi-condition merging, limit/offset/sort), but there are no unit tests covering these cases. Add focused tests for convertWhere() and for each adapter method’s call into IDataEngine to prevent silent query regressions.

Copilot uses AI. Check for mistakes.
// Initialize auth manager
this.authManager = new AuthManager(this.options);
// Get data engine service for database operations
const dataEngine = ctx.getService<any>('data');
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ctx.getService('data') throws when the service is not registered (KernelBase.getService throws on missing services), so this init path will crash instead of falling back to in-memory as the log message suggests. Wrap the lookup in try/catch (or use a non-throwing service existence check) and only warn when the service is absent.

Suggested change
const dataEngine = ctx.getService<any>('data');
let dataEngine: any | undefined;
try {
dataEngine = ctx.getService<any>('data');
} catch {
ctx.logger.warn('No data engine service found - auth will use in-memory storage');
}

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +47

for (const condition of where) {
// Use field names as-is (no conversion needed)
const fieldName = condition.field;

if (condition.operator === 'eq') {
filter[fieldName] = condition.value;
} else if (condition.operator === 'ne') {
filter[fieldName] = { $ne: condition.value };
} else if (condition.operator === 'in') {
filter[fieldName] = { $in: condition.value };
} else if (condition.operator === 'gt') {
filter[fieldName] = { $gt: condition.value };
} else if (condition.operator === 'gte') {
filter[fieldName] = { $gte: condition.value };
} else if (condition.operator === 'lt') {
filter[fieldName] = { $lt: condition.value };
} else if (condition.operator === 'lte') {
filter[fieldName] = { $lte: condition.value };
} else if (condition.operator === 'contains') {
filter[fieldName] = { $regex: condition.value };
}
}

Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When multiple where conditions target the same field (e.g. gte + lte), this loop overwrites filter[fieldName] and drops earlier operators. Accumulate operators into a single object per field (or build a top-level $and) so all conditions are preserved.

Suggested change
for (const condition of where) {
// Use field names as-is (no conversion needed)
const fieldName = condition.field;
if (condition.operator === 'eq') {
filter[fieldName] = condition.value;
} else if (condition.operator === 'ne') {
filter[fieldName] = { $ne: condition.value };
} else if (condition.operator === 'in') {
filter[fieldName] = { $in: condition.value };
} else if (condition.operator === 'gt') {
filter[fieldName] = { $gt: condition.value };
} else if (condition.operator === 'gte') {
filter[fieldName] = { $gte: condition.value };
} else if (condition.operator === 'lt') {
filter[fieldName] = { $lt: condition.value };
} else if (condition.operator === 'lte') {
filter[fieldName] = { $lte: condition.value };
} else if (condition.operator === 'contains') {
filter[fieldName] = { $regex: condition.value };
}
}
for (const condition of where) {
// Use field names as-is (no conversion needed)
const fieldName = condition.field;
// Map better-auth operators to ObjectQL/Mongo-style operator keys
let operatorKey: string | null = null;
switch (condition.operator) {
case 'eq':
operatorKey = '$eq';
break;
case 'ne':
operatorKey = '$ne';
break;
case 'in':
operatorKey = '$in';
break;
case 'gt':
operatorKey = '$gt';
break;
case 'gte':
operatorKey = '$gte';
break;
case 'lt':
operatorKey = '$lt';
break;
case 'lte':
operatorKey = '$lte';
break;
case 'contains':
operatorKey = '$regex';
break;
default:
operatorKey = null;
}
if (!operatorKey) {
continue;
}
const existing = filter[fieldName];
// If there is no existing filter for this field, keep behavior for first eq,
// otherwise start an operator object.
if (existing === undefined) {
if (operatorKey === '$eq') {
// Preserve simple equality as a primitive value when it's the only condition
filter[fieldName] = condition.value;
} else {
filter[fieldName] = { [operatorKey]: condition.value };
}
continue;
}
// There is already a filter for this field: merge the new operator.
if (existing !== null && typeof existing === 'object') {
// Existing is already an operator object; just add/overwrite this operator.
existing[operatorKey] = condition.value;
filter[fieldName] = existing;
} else {
// Existing is a primitive (likely from an earlier eq); wrap it and merge.
const merged: Record<string, any> = {};
merged.$eq = existing;
merged[operatorKey] = condition.value;
filter[fieldName] = merged;
}
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants